今日看到有許多鐵人賽的朋友紛紛完賽,有點好奇目前還有幾位鐵人還在一起努力,於是想到可以撰寫爬蟲 Web scraper
程式來了解一下,而在 Java library 中有個 JSOUP 套件,此套件有提供許多方便易用的 API 可以解析 HTML,使用方法與 CSS
或 jQuery
選擇器類似,也因為 Kotlin 與 Java 整合度非常好,所以 Kotlin 可以直接呼叫 Java Library 讓我們順利處理許多事情,下面我們來介紹 JSOUP
的使用方式與實作範例「鐵人賽比賽現況」
若要使用 JSOUP 套件要記得先引入套件,下面是 Maven
與 Gradle
分別引用方式
Maven
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.13.1</version>
</dependency>
Gradle
compile 'org.jsoup:jsoup:1.13.1'
JSOUP 主要支援四種輸入(Input)方式進行解析成 Document 物件,如下:
從 字串 解析
此方法要注意字串必須包含 head 與 body 元素
val html : String = "<html><head><title>First parse</title></head>" + "<body><p>Parsed HTML into a doc.</p></body></html>";
val doc : Document = Jsoup.parse(html);
從 HTML 片段解析
我們也可以將 HTML Body 元素下的部份元素進行分析,例如一部份的 Div 元素,如下:
val html : String = "<div><p>Lorem ipsum.</p>";
val doc : Document = Jsoup.parseBodyFragment(html);
val body : Element = doc.body();
利用 URL 載入 Document
此方式應該是最常用的方式,利用網頁 url 直接進行分析,其中會使用到 connect 方法,此方法會我們建立一個新的連線,也可以在此方法設定請求細節,例如 cookie、userAgent、timeout等設定,如下:
val doc : Document = Jsoup.connect("http://example.com/").get();
val title : String = doc.title();
利用 File 載入 Document
我們也可以將 HTML 檔案進行讀檔分析,如下:
val input : File = new File("/tmp/input.html");
val doc : Document = Jsoup.parse(input, "UTF-8", "http://example.com/");
在解析方法中,主要會推薦使用兩種方法,再看大家比較喜歡哪一種方式:
DOM 方法
此方法就是利用 DOM
操作的寫法讓我們學習如何在取得的 Document
物件進行取得元素值 Element
,範例如下:
val input : File = new File("/tmp/input.html");
val doc : Document = Jsoup.parse(input, "UTF-8", "http://example.com/");
val content : Element = doc.getElementById("content");
val links : Elements = content.getElementsByTag("a");
for (val link : links) {
val linkHref : String = link.attr("href");
val linkText : String = link.text();
}
[getElementById(String id)]
利用 id 進行尋找[getElementsByTag(String tag)]
利用 tag 進行尋找[getElementsByClass(String className)]
利用 class 進行尋找[getElementsByAttribute(String key)]
利用屬性值進行尋找[siblingElements()]
[firstElementSibling()]
[lastElementSibling()]
[nextElementSibling()]
[previousElementSibling()]
[parent()]
[children()]
[child(int index)]
[attr(String key)]
利用元素 key 值取得元素屬性[attr(String key, String value)]
設定元素屬性[attributes()]
取得所有元素屬性[id()]
, [className()]
and [classNames()]
[text()]
取得元素文字資料[html()]
取得元素 HTML 資料[tag()]
、[tagName()]
取得 Tag 資料[append(String html)]
, [prepend(String html)]
[appendText(String text)]
, [prependText(String text)]
[appendElement(String tagName)]
, [prependElement(String tagName)]
[html(String value)]
選取器方法
此方法類似於 CSS
、jQuery
的選取器使用方法,如下:
val input : File = new File("/tmp/input.html");
val doc : Document = Jsoup.parse(input, "UTF-8", "http://example.com/");
val links : Elements = doc.select("a[href]");
val pngs : Elements = doc.select("img[src$=.png]");
val masthead : Element = doc.select("div.masthead").first();
val resultLinks : Elements = doc.select("h3.r > a");
tagname
利用 Tag 找到元素,例如 a 元素#id
利用 # 符號加上 id 尋找元素.class
利用 . 符號加上 class 值尋找元素[attribute]
設定元素是否包含某個屬性進行進階條件尋找[attr=value]
設定元素是否包含某個屬性欄位與對應值,例如 width=500[attr^=value]
, [attr$=value]
, [attr*=value]
可針對屬性值使用模糊查詢[attr~=regex]
: 針對屬性值使用 regular expression,例如 img[src~=(?i)\.(png|jpe?g)]
el#id
利用元素加上 id 值進行尋找,例如 div#logo
el.class
利用元素加上 class 值進行尋找,例如 div.masthead
el[attr]
利用元素搭配屬性值進行尋找,例如 a[href]
a[href].highlight
上面已經介紹如何取得 Document 物件與取得特定元素 Element,再來想要介紹如何取得元素Elements 的細節資料,例如元素的文字(Text)、連結(href)等欄位,如下範例:
val html : String = "<p>An <a href='http://example.com/'><b>example</b></a> link.</p>";
val doc : Document = Jsoup.parse(html);
val link : Element = doc.select("a").first();
val elementId = doc.id()
val elementTagName = doc.tagName()
val elementClassName = doc.className()
// 取得 An example link.
val text : String = doc.body().text();
// 取得 http://example.com/
val linkHref : String = link.attr("href");
// 取得 example
val linkText : String = link.text();
// 取得 <a href="http://example.com/"><b>example</b></a>
val linkOuterH : String = link.outerHtml();
// 取得 <b>example</b>
val linkInnerH : String = link.html();
如本文開頭所述,這個範例是想了解鐵人賽還有多少參賽者還在一起努力,有多少鐵人朋友已經順利達陣完成30天目標,故我們從鐵人賽頁面的選手列表進行觀察,我們可以開啟瀏覽器的開發者工具了解網站每個元素的規則,這邊將觀察到的規則整理如下:
(1) 開啟瀏覽器開發者工具,觀察每個元素如何進行命名,找出對應的規則
(2) 觀察出關鍵元素-正在挑戰 / 挑戰失敗的樣式差異,如下圖
(3) 接下來,我們利用上述整理的規則進行撰寫程式,說明如下:
@RestController
@RequestMapping("/api")
class HomeController {
@GetMapping("/getIronManData")
fun getData(): HashMap<String, Any> {
// 初始化 API 輸出集合
val response = HashMap<String, Any>()
// 設定爬蟲會用到的基本參數
// 鐵人賽網站連結
val ironManUrl: String = "https://ithelp.ithome.com.tw/2020-12th-ironman/signup/list"
var document = Jsoup.connect(ironManUrl).get()
// 取得全部網站註冊人數
val totalRegisterPerson = document.select(".contestants-num")[0].text().replace("報名數 ", "").toInt()
// 取得每頁參加者數量
val onePageCount = document.select(".contestants-list").size
// 取得全部頁面數量
val totalPageCount = totalRegisterPerson / onePageCount + 1
// 初始化參數
var challengingCount = 0 // 仍正在挑戰中的人數
var challengeSuccessCount = 0 // 挑戰成功的人數
var challengeFailedCount = 0 // 挑戰失敗的人數
var unchallengedCount = 0 // 已經報名,但未開賽的人數
// 初始化每日進度集合
val daysCount = HashMap<String, Int>()
for (index in 0..30) daysCount[index.toString()] = 0
// 帶入每頁頁碼參數
for (page in 1..totalPageCount) {
// 連結加入頁碼參數
document = Jsoup.connect("$ironManUrl?page=$page").get()
// 查詢此頁參加者區塊數量
val cardSize = document.select(".contestants-list").size
// 帶入此頁區塊數量
for (index in 0 until cardSize) {
// 取得區塊元素 Element
val item = document.select(".contestants-list")
// 取得挑戰天數資料
val challengeDay = item.select(".team-dashboard__day")[index].text().replace("DAY ", "").replace("尚未開賽", "0").toString()
// 將該挑戰天數的挑賽人數 + 1
daysCount[challengeDay] = daysCount[challengeDay]!!.toInt().plus(1)
// 取得挑戰狀態
val progressByChallengeStatus = ! item.select(".team-progress--challenge").isEmpty()
val progressByFailStatus = ! item.select(".team-progress--fail").isEmpty()
// 計算挑戰成功、挑戰中、挑戰失敗、已報名未挑戰人數
if (progressByChallengeStatus && !progressByFailStatus && challengeDay.toInt() == 30) challengeSuccessCount++
if (progressByChallengeStatus && !progressByFailStatus && challengeDay.toInt() != 30) challengingCount++
if (!progressByChallengeStatus && progressByFailStatus && challengeDay.toInt() == 0) unchallengedCount++
if (!progressByChallengeStatus && progressByFailStatus && challengeDay.toInt() > 0) challengeFailedCount++
}
}
// 儲存 API 結果進行輸出
response["全部參賽人數"] = totalRegisterPerson
response["挑戰成功人數"] = challengeSuccessCount
response["挑戰進行人數"] = challengingCount
response["挑戰失敗人數"] = challengeFailedCount
response["挑戰進度文章數量(天/篇)"] = daysCount
return response
}
}
(4) 接著執行程式 ,會產生如下 API 爬蟲結果:
(5) 接著,當我們完成爬蟲程式並取得資料結果,後續其實就可以做很多事情,像是資料分析、資料視覺化等動作,下面也是我們針對結果產生出圖表,可以從圖表觀察出目前比賽進度的人數比例:
以上是 JSOUP 爬蟲介紹,建議大家可以練習實作看看,爬蟲程式在實作上不難,但卻可以讓我們在後續實作出很多很有趣的應用。